TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1"use client";2import { useState } from "react";3import Link from "next/link";4import type { ValuationRow } from "@/lib/types";5import type { ToveliEstimate } from "@/lib/estimate";6import { fmtPrice, fmtPct, fmtDate, fmtInt, fmtArea } from "@/lib/format";7import { Tabs, Notice, Stat } from "@/components/ui";8import { PriceHistoryChart } from "@/components/price-chart";9import { MapView } from "@/components/map-view";10import { MortgageWidget } from "@/components/mortgage-widget";11import { EstimateBreakdown } from "@/components/estimate-breakdown";12import type { Insight, MarketPosition } from "@/lib/insights";13import Link2 from "next/link";1415type L = { uid: string; market: string; price: number | null; description: string | null; features: string[]; details: Record<string, unknown>; lat: number | null; lng: number | null; category: string; city: string | null; address: string | null; area_sqft: number | null; year_built: number | null; bedrooms: number | null; bathrooms: number | null; direct?: boolean };16type Fin = { municipalTax: number | null; schoolTax: number | null; assessment: number | null; condoFees: number | null };17type Budget = { rent: number; electricity: number | null; heatingIncluded: boolean; electricityIncluded: boolean; internet: number; total: number; notes: string[] } | null;18type Score = { walk: number | null; transit: number | null; bike: number | null; calm: number | null; services: number | null; global: number | null; version: string; computed_at: string } | null;19type Sale = { id: string; date: string; amount: number; street: string; city: string; property_type: string | null; year_built: number | null; floor_area: number | null };20type Prop = { id: string; role_value: number | null; role_land: number | null; role_building: number | null; key_kind: string; dedup_confidence: number | null; n_listings: number } | null;2122const SKIP_KEYS = new Set(["pieces", "img_audited", "transaction", "prix_pi2", "prix_m2", "inclusions", "appliances", "parking"]);2324export function ListingTabs({ listing: l, valuation, live, priceEvents, score, sales, property, insights = [], position, financials, budget }: { listing: L; valuation: ValuationRow | null; live: ToveliEstimate | null; priceEvents: { ts: string; price: number }[]; score: Score; sales: Sale[]; property: Prop; insights?: Insight[]; position?: MarketPosition; financials?: Fin; budget?: Budget }) {25 const rent = l.market === "rent";26 const items = [{ id: "apercu", label: "Aperçu" }, { id: "prix", label: rent ? "Loyer et marché" : "Prix et marché" }, ...(!rent && live ? [{ id: "comparables", label: "Comparables" }] : []), ...(!rent ? [{ id: "budget", label: "Budget" }] : []), { id: "quartier", label: "Quartier" }];27 const [tab, setTab] = useState("apercu");28 const detailEntries = Object.entries(l.details).filter(([k, v]) => !SKIP_KEYS.has(k) && v != null && typeof v !== "object" && String(v).trim() !== "").slice(0, 40);29 const pieces = Array.isArray(l.details.pieces) ? (l.details.pieces as { nom?: string; niveau?: string; dimensions?: string; revetement?: string }[]) : [];30 const est = live?.result.estimate ?? valuation?.estimate ?? null;31 const method = valuation?.method_detail ? (JSON.parse(valuation.method_detail) as Record<string, number | string | null>) : null;32 return (33 <div className="mt-6">34 <Tabs items={items} current={tab} onChange={setTab} />35 <div className="pt-5 space-y-6">36 {tab === "apercu" && (<>37 {insights.length > 0 && <section className="tv-card p-4"><h2 className="font-bold text-lg mb-2">En bref</h2><ul className="space-y-1.5 text-sm">{insights.map((i) => <li key={i.text} className="flex gap-2"><span className={`mt-1.5 w-2 h-2 rounded-full shrink-0 ${i.tone === "good" ? "bg-good" : i.tone === "warn" ? "bg-warn" : i.tone === "info" ? "bg-info" : "bg-ink-3"}`} aria-hidden /><span className="text-ink-2">{i.text}</span></li>)}</ul><p className="text-[11px] text-ink-3 mt-2">Constats calculés à partir des données de l'annonce et du marché Toveli, sans interprétation éditoriale.</p></section>}38 {budget && <section className="tv-card p-4"><h2 className="font-bold text-lg mb-2">Budget mensuel estimé</h2><div className="grid grid-cols-2 sm:grid-cols-4 gap-3"><Stat label="Loyer" value={fmtPrice(budget.rent)} /><Stat label="Électricité et chauffage" value={budget.electricityIncluded ? "Inclus" : fmtPrice(budget.electricity)} sub={budget.electricityIncluded ? "électricité incluse" : budget.heatingIncluded ? "chauffage inclus, électricité estimée" : "estimation, chauffage électrique"} /><Stat label="Internet" value={fmtPrice(budget.internet)} sub="forfait moyen" /><Stat label="Total indicatif" value={fmtPrice(budget.total)} tone="info" /></div><ul className="text-[11px] text-ink-3 mt-2 list-disc pl-4">{budget.notes.map((n) => <li key={n}>{n}</li>)}</ul></section>}39 <section><h2 className="font-bold text-lg mb-2">Description</h2>{l.description ? <p className="text-sm text-ink-2 whitespace-pre-line leading-relaxed">{l.description}</p> : <p className="text-sm text-ink-3">Aucune description fournie par la source.</p>}</section>40 {l.features.length > 0 && <section><h2 className="font-bold text-lg mb-2">{rent ? "Inclusions et commodités" : "Caractéristiques"}</h2><ul className="grid sm:grid-cols-2 gap-1.5 text-sm text-ink-2">{l.features.slice(0, 40).map((f) => <li key={f} className="flex gap-2"><span className="text-brand">•</span>{f}</li>)}</ul></section>}41 {detailEntries.length > 0 && <section><h2 className="font-bold text-lg mb-2">Détails pratiques</h2><dl className="grid sm:grid-cols-2 gap-x-6 gap-y-1.5 text-sm">{detailEntries.map(([k, v]) => <div key={k} className="flex justify-between gap-3 border-b border-line py-1"><dt className="text-ink-3 capitalize">{k.replace(/_/g, " ")}</dt><dd className="text-right text-ink-2 truncate max-w-[60%]" title={String(v)}>{String(v)}</dd></div>)}</dl></section>}42 {pieces.length > 0 && <section><h2 className="font-bold text-lg mb-2">Pièces</h2><div className="overflow-x-auto"><table className="w-full text-sm"><thead><tr className="text-left text-ink-3 text-xs"><th className="py-1 pr-3 font-medium">Pièce</th><th className="py-1 pr-3 font-medium">Niveau</th><th className="py-1 pr-3 font-medium">Dimensions</th><th className="py-1 font-medium">Revêtement</th></tr></thead><tbody>{pieces.slice(0, 30).map((p, i) => <tr key={i} className="border-t border-line"><td className="py-1 pr-3">{p.nom}</td><td className="py-1 pr-3 text-ink-2">{p.niveau}</td><td className="py-1 pr-3 text-ink-2">{p.dimensions}</td><td className="py-1 text-ink-2">{p.revetement}</td></tr>)}</tbody></table></div></section>}43 </>)}4445 {tab === "prix" && (<>46 {!rent && (live || valuation) ? (47 <section>48 <h2 className="font-bold text-lg mb-3">Valeur estimée et écart avec le prix demandé</h2>49 <div className="grid sm:grid-cols-3 gap-3">50 <Stat label="Estimation Toveli" value={fmtPrice(est)} sub={`${fmtPrice(live?.result.low ?? valuation?.low)} – ${fmtPrice(live?.result.high ?? valuation?.high)}`} />51 <Stat label="Prix demandé" value={fmtPrice(l.price)} sub={est && l.price ? `écart ${fmtPct((l.price - est) / est)} vs estimation` : undefined} tone={est && l.price ? (l.price > est * 1.05 ? "warn" : l.price < est * 0.95 ? "good" : "info") : undefined} />52 <Stat label="Confiance" value={live ? `${live.result.confidenceLevel} · ${live.result.confidencePct} %` : valuation?.confidence ?? "—"} sub={live ? `${live.result.nCompsUsed} comparables${live.result.compsDispersionPct != null ? ` · dispersion ${live.result.compsDispersionPct} %` : ""}` : valuation?.evaluated_at ? `évaluée le ${fmtDate(valuation.evaluated_at)}` : undefined} />53 </div>54 {live && <div className="mt-4"><EstimateBreakdown e={live} price={l.price} /></div>}55 {!live && method && (56 <dl className="mt-4 grid sm:grid-cols-2 gap-2 text-sm">57 {[["Modèle hédonique", method.model_est], ["Comparables ajustés", method.comps_est], ["Méthode du coût", method.cost_est], ["Rôle indexé (ratios IAAO)", method.role_est], ["Ensemble (médiane)", method.ens_est], ["Valeur au rôle", method.valeur_role]].filter(([, v]) => v).map(([k, v]) => <div key={String(k)} className="flex justify-between border-b border-line py-1"><dt className="text-ink-3">{k}</dt><dd className="tabular-nums">{fmtPrice(Number(v))}</dd></div>)}58 </dl>59 )}60 <div className="mt-3"><Notice>Méthode : {live?.method.name ?? "hybride Toveli (modèle hédonique + comparables ajustés)"} · version {live?.method.version ?? valuation?.model_version} · {live ? `calculée aujourd'hui` : `évaluée le ${fmtDate(valuation?.evaluated_at)}`}. {live?.method.disclaimer ?? "Estimation statistique indicative ; ne remplace pas une évaluation agréée (OEAQ)."}</Notice></div>61 {live && live.method.limits.length > 0 && <ul className="mt-2 text-xs text-ink-3 list-disc pl-5 space-y-0.5">{live.method.limits.map((x) => <li key={x}>{x}</li>)}</ul>}62 </section>63 ) : rent && valuation ? (64 <section>65 <h2 className="font-bold text-lg mb-3">Loyer demandé et loyer de référence</h2>66 <div className="grid sm:grid-cols-3 gap-3">67 <Stat label="Loyer de référence (modèle)" value={fmtPrice(valuation.estimate, { rent: true })} sub={`${fmtPrice(valuation.low)} – ${fmtPrice(valuation.high)}`} />68 <Stat label="Loyer demandé" value={fmtPrice(l.price, { rent: true })} sub={valuation.estimate && l.price ? `écart ${fmtPct((l.price - valuation.estimate) / valuation.estimate)}` : undefined} tone={valuation.verdict === "sous" ? "good" : valuation.verdict === "sur" ? "warn" : "info"} />69 <Stat label="Confiance" value={valuation.confidence ?? "—"} sub={method?.comps ? `${method.comps} logements comparables · ${method.segment ?? ""}` : undefined} />70 </div>71 <div className="mt-3"><Notice>Modèle de loyer de référence Toveli ({valuation.model_version}) : médianes par segment (ville/quartier × chambres), ajustements multiplicatifs, voisins géographiques. Indicatif seulement.</Notice></div>72 </section>73 ) : <Notice>Aucune estimation disponible pour cette annonce ({l.category === "terrain" || l.category === "commercial" ? "catégorie non couverte par le modèle" : "données insuffisantes"}).</Notice>}7475 {position?.metric && (76 <section>77 <h2 className="font-bold text-lg mb-2">Position dans le marché {position.metric.scope === "province" ? "québécois" : `· ${position.metric.scope_value}`}</h2>78 <div className="tv-card p-4">79 <div className="relative h-2 rounded-full bg-surface-2 mt-5 mb-6">80 {[["P25", position.metric.p25_price], ["Médiane", position.metric.median_price], ["P75", position.metric.p75_price]].map(([k, v], i) => <span key={String(k)} className="absolute -bottom-5 text-[10px] text-ink-3 -translate-x-1/2" style={{ left: `${[25, 50, 75][i]}%` }}>{k} {fmtPrice(v as number, { compact: true, rent })}</span>)}81 {position.pricePercentile != null && <span className="absolute -top-5 text-[11px] font-semibold text-brand-dark -translate-x-1/2 whitespace-nowrap" style={{ left: `${Math.min(97, Math.max(3, position.pricePercentile))}%` }}>Cette annonce · {position.pricePercentile}e centile<span className="block mx-auto mt-0.5 w-2.5 h-2.5 rounded-full bg-brand" /></span>}82 </div>83 <p className="text-xs text-ink-2">{fmtInt(position.metric.n)} {l.category === "logement" ? "logements" : `${l.category}s`} {rent ? "à louer" : "à vendre"} comparés · {position.metric.new_30d ?? 0} nouvelles en 30 jours · {position.metric.median_days != null ? `${position.metric.median_days} jours médians en ligne` : ""}{position.ppsfVsMedian != null ? ` · prix/pi² ${fmtPct(position.ppsfVsMedian)} vs médiane` : ""}.</p>84 {l.city && <Link2 href={`/marche?ville=${encodeURIComponent(l.city)}&marche=${rent ? "location" : "achat"}`} className="text-sm font-semibold text-brand-dark hover:underline">Toutes les données de {l.city} →</Link2>}85 </div>86 </section>87 )}88 {financials && (financials.municipalTax || financials.schoolTax || financials.assessment || financials.condoFees) && (89 <section><h2 className="font-bold text-lg mb-2">Taxes, évaluation et charges</h2><div className="grid grid-cols-2 sm:grid-cols-4 gap-3">{financials.assessment ? <Stat label="Évaluation municipale" value={fmtPrice(financials.assessment)} sub={l.price && financials.assessment ? `prix = ${(l.price / financials.assessment).toFixed(2)} × évaluation` : undefined} /> : null}{financials.municipalTax ? <Stat label="Taxes municipales" value={fmtPrice(financials.municipalTax)} sub="par année" /> : null}{financials.schoolTax ? <Stat label="Taxes scolaires" value={fmtPrice(financials.schoolTax)} sub="par année" /> : null}{financials.condoFees ? <Stat label="Frais de copropriété" value={fmtPrice(financials.condoFees)} sub="par mois" /> : null}</div><p className="text-xs text-ink-3 mt-1">Montants déclarés par la source ou issus du rôle d'évaluation ; vérifiez auprès de la municipalité.</p></section>90 )}91 <section>92 <h2 className="font-bold text-lg mb-2">Historique du prix demandé</h2>93 {priceEvents.length >= 2 ? <PriceHistoryChart events={priceEvents} rent={rent} /> : <p className="text-sm text-ink-3">Aucun changement de prix observé depuis la première collecte{priceEvents[0] ? ` (${fmtDate(priceEvents[0].ts)})` : ""}.</p>}94 {priceEvents.length >= 2 && <ol className="mt-2 text-xs text-ink-2 space-y-0.5">{priceEvents.slice(-6).reverse().map((e, i, a) => { const prev = a[i + 1]; return <li key={e.ts}>{fmtDate(e.ts)} · {fmtPrice(e.price, { rent })}{prev ? <span className={e.price < prev.price ? "text-good" : "text-warn"}> ({fmtPct((e.price - prev.price) / prev.price)})</span> : " · première observation"}</li>; })}</ol>}95 </section>9697 {property?.role_value != null && (98 <section>99 <h2 className="font-bold text-lg mb-2">Rôle d'évaluation municipal</h2>100 <div className="grid sm:grid-cols-3 gap-3"><Stat label="Valeur au rôle" value={fmtPrice(property.role_value)} /><Stat label="Terrain" value={fmtPrice(property.role_land)} /><Stat label="Bâtiment" value={fmtPrice(property.role_building)} /></div>101 <p className="mt-2 text-xs text-ink-3">Source : rôle d'évaluation foncière (MAMH, Données Québec, licence ouverte). La valeur au rôle reflète les conditions du marché à une date de référence antérieure, pas la valeur actuelle.</p>102 </section>103 )}104 {l.city && <p className="text-sm"><Link href={`/marche?ville=${encodeURIComponent(l.city)}&marche=${rent ? "location" : "achat"}`} className="font-semibold text-brand-dark hover:underline">Voir le marché de {l.city} →</Link></p>}105 </>)}106107 {tab === "comparables" && live && (108 <section>109 <h2 className="font-bold text-lg mb-1">Ventes comparables ajustées</h2>110 <p className="text-sm text-ink-2 mb-3">{live.result.nCompsUsed} ventes retenues parmi les candidates autour de la propriété, pondérées par distance, récence et similarité de superficie. Chaque ajustement est affiché en dollars.</p>111 <div className="overflow-x-auto tv-card">112 <table className="w-full text-sm">113 <thead className="text-xs text-ink-3 text-left"><tr><th className="p-2 font-medium">Vente</th><th className="p-2 font-medium">Date</th><th className="p-2 font-medium text-right">Prix</th><th className="p-2 font-medium text-right">Marché</th><th className="p-2 font-medium text-right">Superficie</th><th className="p-2 font-medium text-right">Âge</th><th className="p-2 font-medium text-right">Ajusté</th><th className="p-2 font-medium text-right">Dist.</th><th className="p-2 font-medium text-right">Poids</th></tr></thead>114 <tbody>{live.result.comps.map((c) => <tr key={c.id} className="border-t border-line"><td className="p-2"><span className="font-medium">{c.street ?? "Adresse non publiée"}</span><br /><span className="text-xs text-ink-3">{c.city} · {c.propertyType ?? "type n/d"}{c.floorArea ? ` · ${Math.round(c.floorArea)} m²` : ""}{c.yearBuilt ? ` · ${c.yearBuilt}` : ""}</span></td><td className="p-2 text-ink-2">{fmtDate(c.date)}</td><td className="p-2 text-right tabular-nums">{fmtPrice(c.amount)}</td><td className={`p-2 text-right tabular-nums ${c.adjTime >= 0 ? "text-good" : "text-warn"}`}>{c.adjTime >= 0 ? "+" : ""}{fmtPrice(c.adjTime)}</td><td className={`p-2 text-right tabular-nums ${c.adjArea >= 0 ? "text-good" : "text-warn"}`}>{c.adjArea >= 0 ? "+" : ""}{fmtPrice(c.adjArea)}</td><td className={`p-2 text-right tabular-nums ${c.adjAge >= 0 ? "text-good" : "text-warn"}`}>{c.adjAge >= 0 ? "+" : ""}{fmtPrice(c.adjAge)}</td><td className="p-2 text-right tabular-nums font-semibold">{fmtPrice(c.adjustedPrice)}</td><td className="p-2 text-right text-ink-2">{c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`}</td><td className="p-2 text-right text-ink-2">{(c.weight * 100).toFixed(0)} %</td></tr>)}</tbody>115 </table>116 </div>117 <p className="mt-2 text-xs text-ink-3">Ajustements : marché = indice mensuel par type ; superficie = 50 % du $/m² du comparable (± 25 % max) ; âge = 0,5 %/an (± 10 % max). Médiane pondérée des prix ajustés → estimation « comparables ». Source des ventes : base de transactions Toveli (ventes ≥ 50 000 $).</p>118 </section>119 )}120121 {tab === "budget" && !rent && l.price && (122 <section><h2 className="font-bold text-lg mb-3">Budget et financement</h2><MortgageWidget price={l.price} city={l.city} compact taxes={financials?.municipalTax ?? undefined} schoolTaxes={financials?.schoolTax ?? undefined} condoFees={financials?.condoFees ?? undefined} /></section>123 )}124125 {tab === "quartier" && (126 <section className="space-y-4">127 {l.lat != null && l.lng != null && <div className="h-72 rounded-card overflow-hidden border border-line"><MapView points={[{ uid: l.uid, lat: l.lat, lng: l.lng, price: l.price ?? 0 }]} center={[l.lat, l.lng]} zoom={14} rent={rent} /></div>}128 {score ? (129 <div>130 <h2 className="font-bold text-lg mb-2">Scores du secteur</h2>131 <div className="grid grid-cols-3 sm:grid-cols-6 gap-2">{([["Global", score.global], ["Marche", score.walk], ["Transport", score.transit], ["Vélo", score.bike], ["Calme", score.calm], ["Services", score.services]] as const).map(([k, v]) => <div key={k} className="tv-card p-3 text-center"><p className="text-2xl font-extrabold tabular-nums">{v != null ? v.toFixed(1) : "—"}</p><p className="text-xs text-ink-3">{k}</p></div>)}</div>132 <p className="mt-2 text-xs text-ink-3">Scores de quartier Toveli ({score.version}) : densité de commerces, arrêts de transport, pistes cyclables, trafic et services dans un rayon de marche, à partir d'OpenStreetMap (© contributeurs OSM, ODbL) et du recensement 2021. Calculés le {fmtDate(score.computed_at)}.</p>133 </div>134 ) : <Notice>Pas de score de quartier disponible pour ce secteur (zone non couverte par les tuiles calculées).</Notice>}135 {sales.length > 0 && (136 <div><h2 className="font-bold text-lg mb-2">Ventes récentes à proximité</h2><ul className="tv-card divide-y divide-line text-sm">{sales.map((s) => <li key={s.id} className="p-2.5 flex justify-between gap-3"><span><span className="font-medium">{s.street}</span><span className="text-ink-3 text-xs"> · {s.city}{s.property_type ? ` · ${s.property_type}` : ""}{s.floor_area ? ` · ${Math.round(s.floor_area)} m²` : ""}</span></span><span className="text-right tabular-nums shrink-0">{fmtPrice(s.amount)}<br /><span className="text-xs text-ink-3">{fmtDate(s.date)}</span></span></li>)}</ul></div>137 )}138 </section>139 )}140 </div>141 </div>142 );143}144